2장. RAG 한 줄 직접 만들기

출처: 『RAG 마스터: 랭체인으로 완성하는 LLM 서비스』(브라이스 유·조경아·박수진·김재웅, 프리렉 2025) | 공식: www.langchain.com

코드는 분위기만 — embed_query·split_documents·| 같은 말은 몰라도 됩니다. 표의 '비유'와 '위험'만 봐도 충분해요.

이 장은 0장에서 그림으로만 봤던 RAG 한 줄을, 이번엔 부품 하나하나 직접 손에 쥐고 만들어 보는 장이다.

문서를 숫자로 바꾸고, 바깥 파일을 읽어 들이고, 길면 자르고, 창고에 넣고, 비슷한 걸 꺼내, 답을 짓는다.

0장 용어와 척추가 머리에 있으면 충분하다. 막히면 0장으로 돌아가면 된다.


0. 이 장의 새 단어 (0장에 없는 것만)

0장 용어집에 없는 말은 딱 3개다.

나머지 어려운 말(임베딩·벡터·청크·검색기·코사인 유사도 등)은 전부 0장에 있다.


메타데이터(metadata)

한 문장 뜻 — 문서 본문 말고, 그 문서에 딸린 꼬리표 정보. 어디서 왔는지(출처)·몇 페이지인지·제목 같은 것.

일상비유 — 택배 상자에 붙은 송장. 상자 안 물건(본문)과 별개로, 보낸 곳·받는 곳·무게가 송장(메타데이터)에 적혀 있다.

한 줄 예 —

# 아래 예제는 핵심 흐름만 짧게 보여 줍니다.
# 본문은 page_content, 꼬리표는 metadata
doc.page_content   # "2024년 주택시장 전망은..."  ← 알맹이
doc.metadata       # {'source': '보고서.pdf', 'page': 8}  ← 꼬리표

유사도 검색 / 상위 k개(similarity search / top-k)

한 문장 뜻 — 질문과 가장 비슷한 조각 몇 개만 골라 꺼내 오는 일. 몇 개 꺼낼지를 정하는 숫자가 k다.

일상비유 — 검색창에 단어를 치면 위에서부터 관련 글 3개만 보여 주는 것. 100만 개를 다 보지 않고 위 몇 개만 본다.

한 줄 예 —

# 질문과 비슷한 조각 상위 3개만 꺼내기
# 문서 벡터를 저장하고 비슷한 내용을 찾을 저장소를 준비합니다.
docs = store.similarity_search("환불 규정", k=3)

스트림릿(Streamlit)

한 문장 뜻 — 파이썬 코드만으로 웹 화면(채팅창·버튼)을 만들어 주는 도구. HTML·CSS를 몰라도 된다.

일상비유 — 조립 안 된 가구 대신, 이미 다 만들어진 책상을 사 오는 것. 채팅창·입력칸이 이미 부품으로 있어 가져다 쓰기만 하면 된다.

한 줄 예 —

# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
import streamlit as st
st.title("내 챗봇")              # 화면에 제목 한 줄
user = st.chat_input("질문하세요")  # 채팅 입력칸 하나

(귀납 도입) 이런 적 있죠?

똑똑한 AI에게 "이 PDF 보고서 내용 알려줘"라고 했더니, 그 파일을 본 적도 없으면서 그럴듯하게 둘러댄다.

0장에서 봤다. AI는 자기가 배운 것 밖을 모른다. 우리 PDF는 배운 적이 없다.

그럼 어떻게 해야 AI가 우리 PDF를 보고 답하게 만들까?

순서는 이렇다.

먼저 PDF 글을 컴퓨터가 비교할 수 있게 숫자로 바꾼다.

그다음 PDF 파일을 읽어 들인다.

90,000자나 되는 긴 글은 한 번에 못 넣으니 조각으로 자른다.

조각들을 창고에 넣어 둔다.

질문이 오면 비슷한 조각만 꺼낸다.

그 조각을 AI에게 쥐여 주며 "이거 보고 답해"라고 한다.

# 이 장에서 만들 한 줄 (지금은 분위기만)
# 이 줄은 예제 흐름을 이루는 실제 처리 단계입니다.
질문 → 비슷한 조각 검색 → 조각 + 질문을 AI에게 → 답

이 한 줄을, 이제 부품별로 하나씩 만들어 본다.


이 장에서 딱 6가지만

TL;DR

  1. 임베딩·코사인 유사도 — 글을 숫자로 바꾸고, 방향이 같으면 비슷하다고 잰다.
  2. 랭체인 임베딩 API — 오픈AI(유료·고품질)와 허깅페이스(무료·로컬) 두 길이 있다.
  3. 문서 로더 — 웹·PDF·CSV 같은 바깥 파일을 같은 모양으로 읽어 들인다.
  4. 텍스트 분할 — 길이로 자르거나(빠름), 의미로 자른다(똑똑·비쌈).
  5. 벡터 DB — 숫자 조각을 창고(Chroma·FAISS)에 넣고 비슷한 걸 빠르게 꺼낸다.
  6. RAG 챗봇 + 스트림릿 — 부품을 한 줄로 잇고, 화면을 입힌다.

학습 목표

  • RAG의 인덱싱 과정과 쿼리 과정을 구분한다.
  • 워드·문장·문서 임베딩을 구분하고 코사인 유사도가 무엇인지 설명한다.
  • 문서 로더·텍스트 분할기·벡터 DB가 각각 무슨 일을 하는지 설명한다.
  • 길이 분할과 의미 분할의 차이를 설명한다.

개념 1 — 임베딩과 코사인 유사도: 글을 숫자로, 비슷함은 방향으로

망가지는 장면

"과일 값이 비싸다"와 "시장 물가가 올랐어요"는 사람 눈엔 비슷하다.

그런데 컴퓨터한테 글자 그대로 비교시키면, 겹치는 글자가 없어 "전혀 다른 글"이라 한다.

의미가 비슷한 글을 영영 못 찾는다.

일상비유

사람마다 키·나이를 숫자 신상카드로 적어 두면, 이름이 달라도 카드 숫자가 비슷한 사람을 찾을 수 있다.

글도 숫자 카드(임베딩)로 바꾸면 의미가 비슷한 글을 찾을 수 있다.

비슷한 정도는 방향으로 잰다. 두 손가락이 같은 곳을 가리키면 비슷한 것이다.

비유 코드 위험
글 → 숫자 신상카드 embeddings.embed_query("저는 배가 고파요") 임베딩 모델이 나쁘면 카드가 엉터리
같은 방향이면 비슷(1) cos_sim(vec2, vec3) → 1.0 1에 가까울수록 비슷
직각(0)·정반대(-1) cos_sim(vec1, vec2) → 0.7 점수 낮으면 관련 없는 글

한 문장 정의 — 임베딩은 글을 숫자 묶음으로 바꾸는 일이고, 코사인 유사도는 두 묶음이 같은 방향을 보는 정도(-1~1)로 비슷함을 재는 방법이다.

예시 폭격

예시 1 (worked — 완성). 임베딩에는 세 종류가 있다. 단위만 다르다.

# 단어 하나를 숫자로 → 워드 임베딩
# 이 줄은 예제 흐름을 이루는 실제 처리 단계입니다.
"랭체인"      → [-0.01, -0.02, 0.03, 0.12]
# 문장 하나를 숫자로 → 문장 임베딩
# 이 줄은 예제 흐름을 이루는 실제 처리 단계입니다.
"안녕하세요"  → [-0.01, -0.05, -0.16, 0.19]
# 문서 통째로 숫자로 → 문서 임베딩
# 이 줄은 예제 흐름을 이루는 실제 처리 단계입니다.
"랭체인은 ..." → [-0.05,  0.21, 0.04, 0.05]

예시 2 (worked — 완성). 코사인 유사도 점수는 이렇게 읽는다.

# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
import numpy as np
# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from numpy import dot
# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from numpy.linalg import norm

# 반복해서 쓸 처리 흐름에 이름을 붙인 함수입니다.
def cos_sim(A, B):
    # 계산하거나 처리한 결과를 이 함수를 부른 쪽으로 돌려줍니다.
    return dot(A, B) / (norm(A) * norm(B))

# 숫자나 표 데이터를 계산하기 좋은 형태로 준비합니다.
vec2 = np.array([1, 0, 2, 1])
vec3 = np.array([2, 0, 4, 2])   # vec2를 딱 2배 한 것
cos_sim(vec2, vec3)  # → 1.0  (크기는 2배지만 방향이 같다 → 매우 비슷)

크기가 2배여도 유사도는 1.0이다.

코사인은 크기를 무시하고 방향만 본다는 증거다.

긴 글과 짧은 글이 길이 차이만으로 "다르다"고 나오는 걸 막아 준다.

예시 3 (부분완성 — 빈칸 채우기). 아래 빈칸에 들어갈 값을 골라 보자.

# 아래 예제는 핵심 흐름만 짧게 보여 줍니다.
# 두 벡터가 정반대 방향이면 코사인 유사도는?
cos_sim([1, 0], [-1, 0])  # → ____
# 보기: 1.0 / 0.0 / -1.0

정답은 -1.0. 정반대 방향이니까.

예시 4 (독립적용 — 미니 시나리오). "과일 값이 비싸다"로 6개 문장 중 비슷한 걸 찾는다.

문장 6개를 모두 숫자 카드로 바꿔 둔다.

질문도 숫자 카드로 바꾼다.

질문 카드와 6개 카드의 코사인 유사도를 각각 잰다.

점수 높은 순으로 3개를 꺼낸다 → '전통 시장', '시장 물가', '주식 시장'.

글자는 안 겹치는데 의미로 찾아냈다.

참고 — 책 2장에서는 점수를 직접 잴 때 코사인 유사도를, 의미 기반 분할(아래 개념 4)에서는 코사인 거리(= 1 − 코사인 유사도)를 쓴다. 유사도는 클수록 비슷, 거리는 작을수록 비슷이다. 같은 걸 뒤집어 본 것뿐이다.


개념 2 — 랭체인 임베딩 API: 유료 길과 무료 길

망가지는 장면

숫자 카드를 만들려면 임베딩 모델이 필요하다.

그런데 모델이 한두 개가 아니다.

어떤 건 돈을 내고 쓰고, 어떤 건 공짜다. 어떤 건 영어를 잘하고, 어떤 건 한국어를 잘한다.

뭘 골라야 할지 막막하다.

일상비유

번역가를 고용하는 것과 같다.

비싼 전문 통역사(오픈AI)는 품질이 좋지만 부를 때마다 돈이 든다.

무료 자원봉사 번역가(허깅페이스 오픈 모델)는 공짜지만 내 컴퓨터에서 직접 돌려야 하고, 분야에 따라 실력 차가 있다.

비유 코드 위험
비싼 전문 통역사 OpenAIEmbeddings(model="text-embedding-3-small") API 호출마다 비용 발생
무료 자원봉사자 HuggingFaceBgeEmbeddings(model_name="BAAI/bge-m3") 복잡한 맥락은 살짝 약함

한 문장 정의 — 랭체인 임베딩 API는 글을 숫자로 바꾸는 두 길을 제공한다. 유료지만 고품질인 LLM 제공사 모델과, 무료지만 로컬에서 돌리는 허깅페이스 오픈 모델이다.

예시 폭격

예시 1 (worked — 완성). 유료 길(오픈AI)로 문장 하나를 숫자로 바꾼다.

# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from langchain_openai import OpenAIEmbeddings

# 문장이나 문서를 의미 벡터로 바꾸는 임베딩 단계를 준비합니다.
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
# 문장이나 문서를 의미 벡터로 바꾸는 임베딩 단계를 준비합니다.
vec = embeddings.embed_query("저는 배가 고파요")
len(vec)   # 1536  ← 숫자 1536개짜리 카드

예시 2 (worked — 완성). 무료 길(허깅페이스 bge-m3)은 모델 이름만 바꾸면 끝이다.

# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from langchain_community.embeddings import HuggingFaceBgeEmbeddings

# 문장이나 문서를 의미 벡터로 바꾸는 임베딩 단계를 준비합니다.
embeddings = HuggingFaceBgeEmbeddings(model_name="BAAI/bge-m3")
# 한국어에 강하고, 내 컴퓨터에서 무료로 돈다

부품 하나(모델)만 갈아 끼웠다.

나머지 코드는 그대로다.

이게 0장 척추 7번 "랭체인은 레고"의 실제 모습이다.

예시 3 (독립적용 — 미니 시나리오). 어떤 길을 고를까?

회사 비용을 아껴야 하고 한국어 문서가 많다 → 무료·한국어 강한 bge-m3.

품질이 최우선이고 비용은 괜찮다 → 오픈AI.

한 걸음 더 ▸ (지금 몰라도 됨) — bge-m3는 '급등', '가격' 같은 키워드가 겹치면 잘 찾지만, "시장 물가"처럼 넓은 맥락으로 이어지는 건 살짝 약하다. 모델마다 잘하는 결이 다르다는 것만 알아 두면 된다. 깊은 비교는 더 나중 주제다.


개념 3 — 문서 로더: 바깥 파일을 같은 모양으로 읽기

망가지는 장면

내 자료는 PDF, 동료 자료는 웹페이지, 엑셀에서 받은 건 CSV다.

이걸 다 다른 방식으로 읽으면, 읽는 코드를 파일 종류마다 새로 짜야 한다.

뒤에 자르고·숫자로 바꾸는 코드도 종류마다 다 달라진다. 끔찍하다.

일상비유

나라마다 다른 콘센트를 한 모양으로 맞춰 주는 멀티 어댑터.

파일이 PDF든 웹이든 CSV든, 어댑터(문서 로더)를 통과하면 전부 같은 모양(Document)으로 나온다.

비유 코드 위험
웹페이지용 어댑터 WebBaseLoader("https://...") 사이트 막으면 못 읽음
PDF용 어댑터 PyPDFLoader("보고서.pdf") 이미지·빈 페이지는 빠질 수 있음
CSV용 어댑터 CSVLoader("거래.csv") 빈 행도 한 조각으로 셈

한 문장 정의 — 문서 로더는 웹·PDF·CSV 등 80종 넘는 바깥 파일을, 본문(page_content)과 꼬리표(metadata)를 가진 같은 모양의 문서로 바꿔 주는 어댑터다.

예시 폭격

예시 1 (worked — 완성). PDF를 읽으면 본문과 꼬리표가 같이 나온다.

# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from langchain_community.document_loaders import PyPDFLoader

# 검색이나 요약에 쓸 원본 문서를 읽어 옵니다.
loader = PyPDFLoader("KB부동산보고서.pdf")
pages = loader.load_and_split()   # 읽고 + 페이지별로 자동 분할
# 결과 한 조각:
# Document(metadata={'source': '...보고서.pdf', 'page': 8},
#          page_content='2024년 주택시장 전망...')

예시 2 (worked — 완성). 같은 PDF라도 어댑터 종류에 따라 속도와 꼬리표 양이 다르다.

로더 속도 (책 실측) 꼬리표(메타데이터) 언제 쓰나
PyPDFLoader 보통 (약 24초) 기본(출처·페이지) 간단한 표준 PDF
PyMuPDFLoader 빠름 (약 2.6초) 풍부(작성자·포맷·전체쪽수) 대용량·여러 파일
PDFPlumberLoader 느림 (약 39초) 풍부(표를 HTML로) 표·구조가 중요할 때

세 로더 모두 같은 PDF를 83개 조각으로 읽었다.

다만 빠른 건 2.6초, 느린 건 39초였다.

급할 땐 빠른 로더, 표가 중요하면 느려도 표 잘 뽑는 로더를 고른다.

예시 3 (부분완성 — 빈칸 채우기). 상황에 맞는 로더를 골라 보자.

# 아래 예제는 핵심 흐름만 짧게 보여 줍니다.
# 200쪽 PDF 100개를 빨리 읽어야 한다 → ____Loader
# 표가 빼곡한 PDF의 표를 살려야 한다 → ____Loader
# 보기: PyPDF / PyMuPDF / PDFPlumber

정답: 빠른 건 PyMuPDF, 표는 PDFPlumber.

예시 4 (독립적용 — 미니 시나리오). 웹페이지 여러 개를 한 번에 읽기.

읽고 싶은 주소를 리스트로 넘긴다.

# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from langchain_community.document_loaders import WebBaseLoader

# 검색이나 요약에 쓸 원본 문서를 읽어 옵니다.
loader = WebBaseLoader([
    # 프롬프트·모델·출력 처리를 하나의 실행 흐름으로 이어 붙입니다.
    "https://python.langchain.com/docs/introduction/",
    # 프롬프트·모델·출력 처리를 하나의 실행 흐름으로 이어 붙입니다.
    "https://langchain-ai.github.io/langgraph/",
])
# 검색이나 요약에 쓸 원본 문서를 읽어 옵니다.
docs = loader.load()
docs[0].metadata   # {'source': '...', 'title': '...', 'language': 'en'}

PDF든 웹이든, 결과 모양(Document)이 똑같다.

그래서 다음 단계(자르기·숫자로 바꾸기)를 파일 종류와 상관없이 그대로 쓸 수 있다.


개념 4 — 텍스트 분할: 길이로 자르기 vs 의미로 자르기

망가지는 장면

KB 부동산 보고서는 90,699자다.

이걸 통째로 AI에 넣으면? 너무 길어서 못 받는다.

설령 받아도, "환불 규정"을 물었는데 책 전체가 딸려 와 정작 필요한 한 단락을 못 짚는다.

그래서 미리 조각(청크)으로 잘라 둬야 한다.

일상비유

두꺼운 책을 페이지별로 찢어 분류함에 꽂는 것.

자르는 방법은 두 가지다.

하나는 자(尺)로 길이 재서 똑같이 자르기(빠름, 단순).

하나는 내용을 읽고 주제 바뀌는 데서 자르기(똑똑, 느림·비쌈).

비유 코드 위험
자로 길이 재서 자르기 RecursiveCharacterTextSplitter(chunk_size=500) 문장 중간에서 뚝 끊길 수 있음
자른 조각끼리 살짝 겹치기 chunk_overlap=50 0이면 경계에서 맥락 끊김
주제 바뀌는 데서 자르기 SemanticChunker(OpenAIEmbeddings()) 똑똑하지만 비용 듦

한 문장 정의 — 텍스트 분할은 긴 문서를 검색하기 좋은 조각으로 자르는 일이다. 길이로 자르는 RecursiveCharacterTextSplitter는 빠르고, 의미로 자르는 SemanticChunker는 조각마다 한 주제가 모이게 한다.

예시 폭격

예시 1 (worked — 완성). 길이로 자르기. 500자 넘지 않게, 50자씩 겹쳐서.

# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from langchain_text_splitters import RecursiveCharacterTextSplitter

# 긴 문서를 검색하기 좋은 작은 조각으로 나눕니다.
splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
# 검색이나 요약에 쓸 원본 문서를 읽어 옵니다.
texts = splitter.split_documents(pages)
len(texts)   # 242  ← 84쪽 PDF가 242조각으로

chunk_size는 한 조각 최대 글자 수.

chunk_overlap은 옆 조각과 겹치는 글자 수.

겹침을 두면 1번 조각 끝 50자가 2번 조각 앞에도 들어가, 경계에서 말이 잘리는 걸 줄인다.

예시 2 (worked — 완성). 길이 자르기의 순서는 큰 단위부터 작은 단위로.

# 기본 구분자 순서: 단락 → 줄 → 공백 → 글자
# 이 줄은 예제 흐름을 이루는 실제 처리 단계입니다.
["\n\n", "\n", " ", ""]
# 먼저 단락(\n\n)으로 자르고, 그래도 길면 줄(\n)로, 또 길면 공백으로...

큰 덩어리부터 자르니 "재귀적(recursive)"이라 부른다.

예시 3 (worked — 완성). 의미로 자르기. 같은 PDF, 방식만 바꾸면 조각 수가 달라진다.

# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from langchain_experimental.text_splitter import SemanticChunker
# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from langchain_openai import OpenAIEmbeddings

# 기본(백분위수 95): 의미 차이 상위 5% 지점에서만 자름
# 긴 문서를 검색하기 좋은 작은 조각으로 나눕니다.
splitter = SemanticChunker(OpenAIEmbeddings())
# 검색이나 요약에 쓸 원본 문서를 읽어 옵니다.
chunks = splitter.split_documents(pages)
len(chunks)   # 165
방식 설정 이름 성격 조각 수(책 실측)
백분위수(기본) "percentile" 의미가 극단적으로 바뀌는 데서만 165
표준편차 "standard_deviation" 평균보다 훨씬 벗어난 데서 (덜 자름) 84
사분위수 "interquartile" 가운데 50% 기준, 튀는 값에 덜 민감 145

세 방식 모두 코사인 거리로 "문장끼리 얼마나 다른가"를 재고, 많이 다른 데서 자른다.

기준값을 낮추면 조각이 많아지고(짧아짐), 높이면 적어진다(길어짐).

예시 4 (부분완성 — 빈칸). 둘 중 뭘 고를까?

# 아래 예제는 핵심 흐름만 짧게 보여 줍니다.
# 일단 빨리 돌려 보고 싶다 (비용 0)        → ____TextSplitter
# 검색 정확도가 핵심이고 비용은 괜찮다     → ____Chunker
# 보기: RecursiveCharacter / Semantic

정답: 빠른 건 RecursiveCharacter, 정확한 건 Semantic.

예시 5 (독립적용 — 미니 시나리오). 90,699자 보고서를 1,000자 조각으로.

chunk_size=1000, chunk_overlap=200으로 자른다.

84쪽이 138조각이 된다.

가장 긴 조각 966자, 가장 짧은 56자, 평균 664자.

모든 조각이 1,000자를 넘지 않는 걸 확인할 수 있다.

한 걸음 더 ▸ (지금 몰라도 됨) — 길이로 자르면 문장이 뚝 끊긴 불완전한 조각이 생겨, RAG 챗봇 답 품질이 떨어질 수 있다. 그래서 의미 기반 분할이 나왔다. 단, 의미 분할은 문장마다 임베딩 API를 부르니 돈이 더 든다. 어느 쪽이 나은지는 문서와 상황에 따라 다르다. 지금은 "두 가지 방법이 있다"만 알면 충분하다.


개념 5 — 벡터 DB: 숫자 조각 창고와 빠른 꺼내기

망가지는 장면

앞에서는 판다스(엑셀 비슷한 표)에 숫자 조각을 담고, 직접 코사인 유사도를 다 계산했다.

조각이 6개일 땐 괜찮다.

그런데 100만 개면? 질문 하나에 100만 번 계산해야 한다. 너무 느리다.

일상비유

신상카드를 잔뜩 모았으면, 그냥 상자에 쏟아 두지 말고 잘 정리된 캐비닛에 넣어 둔다.

"나랑 비슷한 사람" 카드를 순식간에 뽑아 주는 캐비닛이 벡터 DB다.

대표 캐비닛이 둘. 가벼운 Chroma, 빠른 FAISS.

비유 코드 위험
가벼운 캐비닛(프로토타입) Chroma.from_documents(splits, embedding) 큰 규모엔 한계
빠른 대형 캐비닛(대규모) FAISS.from_documents(splits, embedding) 설정·분산은 까다로움
캐비닛에서 비슷한 카드 꺼내기 store.similarity_search("질문", k=3) k 안 정하면 기본 4개

한 문장 정의 — 벡터 DB는 숫자 조각(임베딩)을 잔뜩 저장하고 "비슷한 것 찾아줘"를 빠르게 해 주는 창고다. 가벼운 Chroma와 빠른 FAISS가 대표이며, 둘 다 as_retriever()로 검색기로 바꿔 쓴다.

예시 폭격

예시 1 (worked — 완성). Chroma에 넣고, 비슷한 조각 꺼내기.

# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from langchain_chroma import Chroma
# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from langchain_openai import OpenAIEmbeddings

# 138개 조각을 숫자로 바꿔 창고에 저장
# 검색이나 요약에 쓸 원본 문서를 읽어 옵니다.
vectordb = Chroma.from_documents(
    # 검색이나 요약에 쓸 원본 문서를 읽어 옵니다.
    documents=splits,
    # 문장이나 문서를 의미 벡터로 바꾸는 임베딩 단계를 준비합니다.
    embedding=OpenAIEmbeddings(),
    persist_directory="./chroma_db",   # 이 경로에 파일로 영구 저장
)

# "수도권 주택 매매 전망"과 비슷한 조각 3개 꺼내기
# 문서 벡터를 저장하고 비슷한 내용을 찾을 저장소를 준비합니다.
docs = vectordb.similarity_search("수도권 주택 매매 전망", k=3)

persist_directory를 적으면 디스크(chroma.sqlite3)에 남아, 다음에 또 쓴다.

안 적으면 메모리에만 있다가 끄면 사라진다.

예시 2 (worked — 완성). FAISS는 거의 같지만 저장·확인 방법이 다르다.

# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from langchain_community.vectorstores import FAISS

# 검색이나 요약에 쓸 원본 문서를 읽어 옵니다.
faiss_db = FAISS.from_documents(documents=splits, embedding=OpenAIEmbeddings())
faiss_db.index.ntotal      # 138  ← Chroma는 _collection.count()

faiss_db.save_local("./faiss_index")   # Chroma는 persist_directory로 자동

같은 일(저장·확인)을 하는데 메서드 이름만 다르다.

Chroma는 persist_directory·_collection.count(), FAISS는 save_local()·index.ntotal.

예시 3 (부분완성 — 빈칸). 캐비닛을 골라 보자.

# 아래 예제는 핵심 흐름만 짧게 보여 줍니다.
# 혼자 빠르게 실험·프로토타입                    → ______
# 수백만 벡터를 GPU로 초고속 검색해야 함          → ______
# 보기: Chroma / FAISS

정답: 가벼운 실험은 Chroma, 대규모 초고속은 FAISS(메타 개발, GPU 가속).

예시 4 (독립적용 — 미니 시나리오). 창고를 검색기로 바꾸기.

창고는 그냥 두면 "저장소"다.

as_retriever()를 부르면 질문 받아 꺼내 주는 "검색기"가 된다.

# 문서 벡터를 저장하고 비슷한 내용을 찾을 저장소를 준비합니다.
retriever = vectordb.as_retriever(search_kwargs={"k": 3})
# 이제 retriever.invoke("질문") 하면 비슷한 조각 3개를 돌려줌

중요한 점 하나.

as_retriever()는 Chroma든 FAISS든 똑같이 쓴다.

한번 검색기로 만들면, 창고 종류와 상관없이 다음 단계(챗봇)에 꽂을 수 있다.

참고 — FAISS를 파일에서 다시 불러올 땐 allow_dangerous_deserialization=True를 켠다. 파이썬이 "이 파일 위험할 수 있다"고 막는 걸 푸는 옵션이다. 내가 방금 저장한 파일일 때만 켠다.


개념 6 — RAG 챗봇과 스트림릿: 부품을 한 줄로 잇고 화면 입히기

망가지는 장면

부품은 다 만들었다. 로더, 분할기, 임베딩, 창고, 검색기.

그런데 따로 놀면 챗봇이 아니다.

질문이 오면 → 검색기로 조각 찾고 → 조각을 프롬프트에 넣고 → AI에게 보내 답받는 한 줄로 이어야 한다.

게다가 까만 터미널 화면은 친구에게 보여 주기 민망하다.

일상비유

컨베이어 벨트(체인). 질문을 올리면 검색→프롬프트→모델→정리를 차례로 거쳐 답이 나온다.

그리고 그 위에 예쁜 채팅창(스트림릿)을 씌운다. 손님은 벨트 속을 몰라도 채팅만 하면 된다.

비유 코드 위험
부품을 벨트로 잇기 chain = 검색 \| prompt \| model \| parser 부품 모양 안 맞으면 안 이어짐
이전 대화 기억하기 RunnableWithMessageHistory(...) 없으면 방금 말도 까먹음
채팅 화면 입히기 st.chat_input("질문하세요") 없으면 까만 터미널뿐
무거운 준비는 한 번만 @st.cache_resource 없으면 질문마다 PDF 재로드

한 문장 정의 — RAG 챗봇은 검색기·프롬프트·모델을 한 줄(체인)로 잇고 대화 기억을 붙인 것이며, 스트림릿은 그 위에 파이썬만으로 채팅 화면을 입히는 도구다.

예시 폭격

예시 1 (worked — 완성). 부품을 한 줄로 잇기. 0장 척추 6번 "RAG는 한 줄"의 실제 코드다.

# 반복해서 쓸 처리 흐름에 이름을 붙인 함수입니다.
def format_docs(docs):
    return "\n\n".join(d.page_content for d in docs)   # 조각들을 한 덩어리로

# 프롬프트·모델·출력 처리를 하나의 실행 흐름으로 이어 붙입니다.
chain = (
    # 준비한 함수나 객체를 호출해 예제의 핵심 동작을 실행합니다.
    RunnablePassthrough.assign(
        context=lambda x: format_docs(retriever.invoke(x["question"]))  # ① 검색
    )
    | prompt    # ② 질문+조각을 지시문에 끼움
    | model     # ③ AI에게 보냄
    | StrOutputParser()   # ④ 답을 깔끔한 글자로
)

|(파이프) 하나하나가 "그다음으로 넘긴다"는 뜻이다.

검색 → 프롬프트 → 모델 → 정리. 0장에서 본 그 한 줄 그대로다.

예시 2 (worked — 완성). 대화 기억 붙이기. 이게 있어야 "방금 뭐라 했지?"에 답한다.

# `chat_history`에 중간 결과를 담아 다음 줄에서 재사용합니다.
chat_history = ChatMessageHistory()
# 프롬프트·모델·출력 처리를 하나의 실행 흐름으로 이어 붙입니다.
chain_with_memory = RunnableWithMessageHistory(
    # 프롬프트·모델·출력 처리를 하나의 실행 흐름으로 이어 붙입니다.
    chain,
    # 이 줄은 예제 흐름을 이루는 실제 처리 단계입니다.
    lambda session_id: chat_history,
    # 모델에게 어떤 역할과 입력을 줄지 프롬프트 틀을 만듭니다.
    input_messages_key="question",
    # 모델에게 어떤 역할과 입력을 줄지 프롬프트 틀을 만듭니다.
    history_messages_key="chat_history",
)

이 기억 덕분에, 사용자가 "방금 제가 뭐라고 했죠?" 물으면 챗봇이 직전 말을 그대로 짚어 준다.

예시 3 (worked — 완성). 화면 입히기. 채팅창 한 줄이면 된다.

# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
import streamlit as st

# 준비한 함수나 객체를 호출해 예제의 핵심 동작을 실행합니다.
st.title("KB 부동산 보고서 챗봇")
# 조건을 확인해서 상황에 맞는 처리 경로를 고릅니다.
if user_input := st.chat_input("부동산 질문을 입력하세요"):
    # 파일·연결 같은 자원을 열고, 끝나면 자동으로 정리되게 묶습니다.
    with st.spinner("답변 생성 중..."):
        # 준비한 흐름에 실제 입력을 넣어 결과를 확인합니다.
        response = chain.invoke({"question": user_input}, ...)
        # 준비한 함수나 객체를 호출해 예제의 핵심 동작을 실행합니다.
        st.markdown(response)

HTML·CSS는 한 줄도 없다.

파이썬만으로 채팅창·로딩 표시·답 출력이 다 된다.

예시 4 (부분완성 — 빈칸). 스트림릿 챗봇이 질문할 때마다 PDF를 다시 읽어 너무 느리다. 무엇을 빠뜨렸나?

# 무거운 준비(PDF 로드·창고 만들기) 함수 위에 빠진 한 줄:
# 아래 함수나 클래스를 프레임워크가 특별한 용도로 인식하게 표시합니다.
@____________
# 반복해서 쓸 처리 흐름에 이름을 붙인 함수입니다.
def build_chain():
    ...
# 보기: st.cache_resource

정답: @st.cache_resource. 이게 있어야 무거운 준비를 딱 한 번만 하고 결과를 재사용한다.

예시 5 (독립적용 — 미니 시나리오). 완성된 챗봇과의 실제 대화.

사용자: "랭체인 챗봇 만들기 실습 중입니다."

사용자: "방금 제가 뭐라고 했죠?" → 챗봇이 직전 말을 정확히 짚어 준다(기억 덕분).

사용자: "노후계획도시가 어디야?" → 보고서 조각을 찾아 "분당·일산·중동·평촌·산본"이라 답한다(검색 덕분).

기억 + 검색이 합쳐져, 우리 PDF 기반의 똑똑한 챗봇이 완성됐다.


전체 한 줄 다시 보기

이 장에서 만든 부품을 순서대로 이으면 RAG 한 줄이다.

[인덱싱 — 미리 준비]
문서 → 문서 로더 → 텍스트 분할 → 임베딩 → 벡터 DB 저장

[쿼리 — 질문 올 때마다]
질문 → 검색기(비슷한 조각 k개) → 프롬프트 → 모델 → 답

인덱싱은 도서관에 책을 미리 분류해 꽂아 두는 일.

쿼리는 손님이 물을 때 찾아다 주는 일.

0장 척추 6번에서 그림으로 봤던 그 한 줄을, 이번엔 직접 손으로 만들었다.


단순 규칙 (헷갈리면 이대로)

  • 임베딩 모델: 일단 오픈AI(고품질)로 시작, 비용·한국어 중요하면 bge-m3(무료).
  • 로더: 보통 PyPDFLoader, 빠른 게 필요하면 PyMuPDFLoader, 표가 중요하면 PDFPlumberLoader.
  • 분할: chunk_size=1000, chunk_overlap=200으로 시작, 정확도 보며 조정.
  • 창고: 실험은 Chroma, 수백만 규모는 FAISS.
  • 검색 개수: k=3 명시(안 하면 기본 4개라 토큰 낭비).
  • 스트림릿: 무거운 준비 함수엔 @st.cache_resource 꼭.

더 해보기

검증된 공식 실습·문서 (생존 확인 2026-05-21).

최신 동향 (검증 2026-05-21) — 책은 2025년(랭체인 0.3) 기준이라, 그 뒤 바뀐 것만 짧게. 책의 text-embedding-ada-002 대신 text-embedding-3-small/-large가 현행 오픈AI 임베딩이다(차원 축소 옵션으로 저장 비용 절감 가능). 모델 이름·가격은 수시로 바뀌니 쓸 때 공식 목록을 확인하라. 본문 코드도 이 현행 모델로 적었다.



연습문제

  1. 설명. RAG 한 줄 직접 만들기의 핵심을 처음 듣는 사람에게 한 문장으로 설명하라.
  2. 구분. 두 개념(검색, 생성)을 실제 예시 하나로 구분하라.
  3. 적용. 내 프로젝트나 학습 노트에서 이 장의 개념을 적용해 작게 개선할 지점을 하나 고르라.

부록 A. 쉬운 용어 사전

용어 아주 쉬운 뜻 이 장에서 나온 위치
검색 질문에 답하기 전에 관련 자료나 근거를 찾아오는 일. 부록 B와 본문 예시
생성 찾은 근거와 요청을 바탕으로 새 답변 문장을 만드는 일. 부록 B와 본문 예시
문서 로더 PDF, 웹, 파일 같은 자료를 프로그램이 읽을 수 있는 문서 형태로 바꾸는 부품. 부록 B와 본문 예시
텍스트 분할 긴 문서를 검색하기 좋게 작은 조각으로 나누는 작업. 부록 B와 본문 예시

부록 B. 헷갈리는 개념 비교표

A B 구분 포인트
검색 생성 검색은 근거를 찾고, 생성은 그 근거를 바탕으로 답을 만든다.
문서 로더 텍스트 분할 로더는 읽어 오고, 분할은 읽어 온 것을 작은 조각으로 나눈다.

부록 C. 더 읽을 자료

  • 이 장의 더 해보기 섹션 — 이미 모아 둔 공식 문서나 실습 링크가 있으면 여기서 먼저 확인한다.
  • 같은 책의 0장 한눈에 보기 — 용어가 막히면 0장의 용어집과 개념 척추로 돌아간다.
  • 원본 딥다이브판 같은 장 — 입문판을 읽고 큰 흐름이 잡힌 뒤 세부 논리를 더 깊게 확인한다.
  • 이 장의 flashcards.json — 읽은 직후 질문만 보고 답을 떠올리는 회상 연습에 쓴다.

부록 D. 연습문제 풀이

  1. 설명 예시. RAG 한 줄 직접 만들기는 RAG에서 자료를 더 잘 찾고, 근거를 더 안전하게 붙이고, 답변 흐름을 더 다루기 쉽게 만드는 방법을 보는 장이다. 중요한 것은 용어를 외우는 것이 아니라, 이 개념이 어떤 입력·부품·결정에 영향을 주는지 말로 풀어 보는 것이다.
  2. 구분 예시. 두 개념(검색, 생성)의 차이는 이렇게 잡으면 된다. 검색은 근거를 찾고, 생성은 그 근거를 바탕으로 답을 만든다. 실제 사례를 볼 때는 목적, 입력, 실패했을 때의 증상을 따로 적어 보면 헷갈리지 않는다.
  3. 적용 예시. 가장 작은 개선부터 고른다. 예를 들어 이름을 더 분명히 하거나, 평가 기준을 한 줄 추가하거나, 직접 알 필요 없는 내부 정보를 감추는 식으로 시작한다. 한 번에 크게 갈아엎는 것보다 작은 변경 하나를 확인하며 진행하는 쪽이 입문 단계에 맞다.

다음 장 예고

다음 장에서는 글뿐 아니라 이미지·표까지 함께 다루는 RAG로 넓혀 간다.

이 장의 한 줄(로드→분할→임베딩→저장→검색→생성)이 머리에 있으면 충분하다. 나머지는 다음 장에서 천천히 얹는다.

난이도
에피소드
질문
카드를 로딩 중...
답변

클릭하거나 Space를 눌러 뒤집기

0 / 0
학습 진도 0%
이동   Space 뒤집기   R 셔플